Network FirewallとNAT Gatewayを使えば、VPCエンドポイントは不要なのか検証してみた
VPCエンドポイントっている?
こんにちは、のんピ です。
前回のこちらの記事を書いた後、ふと疑問が湧いてきました。
それは、Network FirewallとNAT Gatewayの環境にはVPCエンドポイントって必要??という疑問です。
VPCエンドポイントの利用シーンとしては、以下があると考えています。
- プライベートサブネットにあるリソースからAWSのリソースにインターネットを経由せず、セキュアにアクセスしたい
- Direct Connectを経由してオンプレ環境からセキュアにAWSのリソースにアクセスしたい
- VPCエンドポイントポリシーを使って、サービスへのアクセスを制限をしたい
この利用シーンの中でも特にVPCエンドポイントを利用する理由になるのが、
1. プライベートサブネットにあるリソースからAWSのリソースにインターネットを経由せず、セキュアにアクセスしたい
だと個人的には感じます。
このような要件がある背景は、セキュリティの観点でインターネットは危険が危ないといった認識があるためです。最強のマルウェア対策がネットワークに繋がないという考えもあると思うので納得です。
ここで、タイムリーな話題として先日、AWSのFAQが更新され、AWSのネットワークから発信され送信先もAWSの場合は、インターネットを経由せずAWSのネットワーク内に留まると、明記されるようになりました。
Q:2 つのインスタンスがパブリック IP アドレスを使用して通信する場合、またはインスタンスが AWS のサービスのパブリックエンドポイントと通信する場合、トラフィックはインターネットを経由しますか?
いいえ。パブリックアドレススペースを使用する場合、AWS でホストされているインスタンスとサービス間のすべての通信は AWS のプライベートネットワークを使用します。AWS ネットワークから発信され、AWS ネットワーク上の送信先を持つパケットは、AWS 中国リージョンとの間のトラフィックを除いて、AWS グローバルネットワークにとどまります。
そのため、プライベートサブネットがAWSのAPIを叩くために、NAT Gatewayを経由したとしてもAWSのネットワーク内に完結するということになります。
加えて、以下公式ドキュメントの通り、エンドポイントのFQDNは法則性があります。
ほとんどの Amazon Web Services では、リクエストの実行に使用できるリージョンのエンドポイントを提供しています。リージョンエンドポイントの一般的な構文は次のとおりです。
protocol[:]//service-code.region-code.amazonaws.com
たとえば、https://dynamodb.us-west-2.amazonaws.com は 米国西部 (オレゴン) リージョンの Amazon DynamoDB サービスのエンドポイントです。
そこで、Network Firewallのドメインフィルタリングを上手く使えば、VPCエンドポイントを使わなくても運用出来ちゃうんじゃないかと考えつきました。
いきなりまとめ
許可するドメインを..amazonaws.com
とすることでAWSのAPIにアクセスすることは可能です。
しかし、以下の理由からVPCエンドポイントを使って運用する必要があると感じました。
- ドメインによるフィルタリングの設定しかされていない場合、IPアドレスを直接指定した通信は、Network Firewallを通過してしまう
- 対策としてIPアドレス制限をすると、エンドポイントにアクセスできなくなってしまう
- 宛先を
0.0.0.0/0 tcp/443
で許可すると、ドメインフィルタリングのルールがあったとしても、tcp/443であればどんな通信もできてしまう - 基本的に課金額はVPCエンドポイントを使った構成の方が安くなる
- より厳密にアクセス元のリソースを制限したい場合は、VPCエンドポイントのエンドポリシーを使う必要がある
- S3のGateway型VPCエンドポイントは通信量がかからないというメリットがあるので、大量にS3と通信が発生する場合は、VPCエンドポイントを使った方がお財布に優しい
検証してみた
CDKでデプロイ
CDKの確認
私はCDKが大好きなので、例によってCDKでデプロイします。CDK自体は前回の記事とほぼほぼ一緒です。
ドメインフィルタリングの対象ドメインを変更します。SSMセッションマネージャーでEC2インスタンスにログインしてみたいので、SSMなど必要なエンドポイントを許可するよう以下のように定義しました。
// Create Network Firewall rule group const networkfirewallRuleGroup = new networkfirewall.CfnRuleGroup( this, "NetworkFirewallRuleGroup", { capacity: 100, ruleGroupName: "WindowsUpdateRuleGroup", type: "STATEFUL", ruleGroup: { rulesSource: { rulesSourceList: { generatedRulesType: "ALLOWLIST", targetTypes: ["TLS_SNI", "HTTP_HOST"], targets: [ "ssm.us-east-1.amazonaws.com", "ssmmessages.us-east-1.amazonaws.com", "ec2messages.us-east-1.amazonaws.com", ], }, }, }, } );
その他の前回の記事からの変更箇所は以下の通りです。
- VPCエンドポイント関連の定義を削除
- CloudWatch Agent関連の定義を削除
- OSのバージョンをWindows Server 2012 R2から、Amazon Linux 2に変更
- Multi-AZからSingle-AZに構成を変更
- 不要なモジュールを削除
全体のアーキテクチャーは以下の通りです。
CDKのデプロイ
CDKのデプロイをします。特にエラーはなく正常に実行完了しました。
> npx cdk deploy This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening). Please confirm you intend to make the following modifications: IAM Statement Changes ┌───┬─────────────────────────────────┬────────┬─────────────────────────────────┬───────────────────────────────────┬───────────┐ │ │ Resource │ Effect │ Action │ Principal │ Condition │ ├───┼─────────────────────────────────┼────────┼─────────────────────────────────┼───────────────────────────────────┼───────────┤ │ + │ ${FlowLogsIamrole.Arn} │ Allow │ sts:AssumeRole │ Service:vpc-flow-logs.amazonaws.c │ │ │ │ │ │ │ om │ │ │ + │ ${FlowLogsIamrole.Arn} │ Allow │ iam:PassRole │ AWS:${FlowLogsIamrole} │ │ ├───┼─────────────────────────────────┼────────┼─────────────────────────────────┼───────────────────────────────────┼───────────┤ │ + │ ${FlowLogsLogGroup.Arn} │ Allow │ logs:CreateLogStream │ AWS:${FlowLogsIamrole} │ │ │ │ │ │ logs:DescribeLogStreams │ │ │ │ │ │ │ logs:PutLogEvents │ │ │ ├───┼─────────────────────────────────┼────────┼─────────────────────────────────┼───────────────────────────────────┼───────────┤ │ + │ ${SsmIamRole.Arn} │ Allow │ sts:AssumeRole │ Service:ec2.${AWS::URLSuffix} │ │ └───┴─────────────────────────────────┴────────┴─────────────────────────────────┴───────────────────────────────────┴───────────┘ IAM Policy Changes ┌───┬───────────────┬────────────────────────────────────────────────────────────────────┐ │ │ Resource │ Managed Policy ARN │ ├───┼───────────────┼────────────────────────────────────────────────────────────────────┤ │ + │ ${SsmIamRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore │ │ + │ ${SsmIamRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonSSMPatchAssociation │ │ + │ ${SsmIamRole} │ arn:${AWS::Partition}:iam::aws:policy/CloudWatchAgentAdminPolicy │ └───┴───────────────┴────────────────────────────────────────────────────────────────────┘ Security Group Changes ┌───┬───────────────────────────────────────────────┬─────┬────────────┬─────────────────┐ │ │ Group │ Dir │ Protocol │ Peer │ ├───┼───────────────────────────────────────────────┼─────┼────────────┼─────────────────┤ │ + │ ${Ec2Instance0/InstanceSecurityGroup.GroupId} │ Out │ Everything │ Everyone (IPv4) │ └───┴───────────────────────────────────────────────┴─────┴────────────┴─────────────────┘ (NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299) Do you wish to deploy these changes (y/n)? y AppStack: deploying... AppStack: creating CloudFormation changeset... [██████████████████████████████████████████████████████████] (35/35) ✅ AppStack Stack ARN: arn:aws:cloudformation:us-east-1:<AWSアカウントID>:stack/AppStack/9596d0d0-aa40-11eb-ab32-12ae8fb95b53
動作確認
早速セッションマネージャーで接続してみます。 SSMのコンソールを確認すると、インスタンスはマネージドインスタンスとして認識されているので、セッションマネージャーで接続ができそうです。
セッションマネージャーで接続できました。 念のためインターネットに通信できないか確認してみましたが、正しくフィルタリングされており通信はできませんでした。
sh-4.2$ curl -m 5 https://dev.classmethod.jp/ curl: (28) Operation timed out after 5000 milliseconds with 0 out of 0 bytes received sh-4.2$ sh-4.2$ curl -m 5 http://update.microsoft.com curl: (28) Operation timed out after 5001 milliseconds with 0 bytes received sh-4.2$
ドメインフィルタリングしか設定しない場合、IPアドレスによる通信は許可される
ここで新たに疑問が湧いてきました。最初にVPCエンドポイントが使われる理由で、
1. プライベートサブネットにあるリソースからAWSのリソースにインターネットを経由せず、セキュアにアクセスしたい
を列挙しましたが、インターネットにアクセスするときはIPアドレスを直接指定することもありますよね。
そこで、Google Public DNSのIPアドレスである8.8.8.8
に対してping
を打ってみます。
sh-4.2$ ping 8.8.8.8 -c 4 PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data. 64 bytes from 8.8.8.8: icmp_seq=1 ttl=108 time=4.39 ms 64 bytes from 8.8.8.8: icmp_seq=2 ttl=108 time=2.39 ms 64 bytes from 8.8.8.8: icmp_seq=3 ttl=108 time=2.42 ms 64 bytes from 8.8.8.8: icmp_seq=4 ttl=108 time=2.53 ms --- 8.8.8.8 ping statistics --- 4 packets transmitted, 4 received, 0% packet loss, time 3004ms rtt min/avg/max/mdev = 2.390/2.937/4.396/0.846 ms sh-4.2$
なんと通信できてしまいました。ドメインを使ってアクセスしていない場合は、ドメインフィルタリングは機能しないようです。
対応としてIPアドレスの制御ができる5-tuple
で以下のように全ての通信を拒否するような制限をかけてみました。
すると、マネージドインスタンスには登録されてはいるようですが、SSMエージェントのpingの状態
が接続が失われました
となっていました。
この状態でセッションマネージャーで接続しようとしても以下のようなエラーが出力され、接続できませんでした。
一旦、cdk destroy
をして再度cdk deploy
しましたが、今度はいつまで待ってもマネージインスタンスとして登録されませんでした。
クライアント側でIPアドレスをキャッシュして、最初に名前解決した後はIPアドレスでアクセスしに行く事もあるのかな?と思ったので必要な通信を許可をしてみます。
エンドポイントにはtcp/443でアクセスする必要があります。しかし、エンドポイントのIPアドレスは公開されていません。そのため、宛先を0.0.0.0/0 tcp/443
で許可する必要があります。
5-tuple
で許可して、再度確認します。
マネージドインスタンスとして登録されました!!
もちろん、このようなルールを設定してしまうと、tcp/443の通信は全て許可されてしまいます。そのため、AWSのエンドポイントだろうが、野良サーバーだろうが通過してしまいます。 企画倒れ感が半端ないですが、AWS以外のリソースと通信することができてしまうことから、Network FirewallはVPCエンドポイントの代替にはならないことが分かりました。 VPCエンドポイントは必要です。ごめんなさい。
注意点を考える
.amazonaws.comはエンドポイントと限らない
.amazonaws.com
で許可したら使いたいサービスが増える毎にフィルタリングのルールを更新しなくても良いんじゃない? と思った時期が私にもありました。
.amazon.com
はエンドポイントとは限りません。例えば、パブリックIPアドレスを持ったEC2インスタンスのDNS名はec2-3-139-90-234.us-east-2.compute.amazonaws.com
になったりします。
横着せずに心を込めて使用するサービス毎にDomain Listに追加しましょう。
VPCエンドポイントポリシーのように細かいアクセス制限をかけられない
Network FirewallはTCP/IPレベルの単純な制御しかできません。 そのため、VPCエンドポイントのように特定リソースに対して特定アクションしかさせないということはできません。
VPCエンドポイントとはなんぞや?という方は以下の記事が参考になるかと思います。
課金額試算
VPCエンドポイントで構成した場合と、Network Firewallで頑張る構成で課金額を試算してみました。
1. VPCエンドポイントが存在する構成
前提は以下の通りです。
- 1ヶ月730時間で計算
- バージニア北部(us-east-1)
- Single-AZ構成
- Interface型のVPCエンドポイント数:
10
- Gateway型のVPCエンドポイントの数(S3):
1
- NAT Gatewayの数:
1
- 1ヶ月あたりのInterface型のVPCエンドポイントのデータ処理量:
1GB
- 1ヶ月あたりのS3へのデータ通信量:
10GB
- 1ヶ月あたりのNAT Gatewayのデータ処理量:
10GB
計算した結果、106.31 USD
となりました。
内訳は以下のようになりました。
- Interface型のVPCエンドポイントについての課金
- 730 hours in a month x 0.01 USD =
7.30 USD
(Hourly cost for endpoint ENI) - 10 VPC endpoints x 1 ENIs per VPC endpoint x 7.30 USD =
73.00 USD
(Total PrivateLink endpoints and data processing cost) - 1 GB per month x 0.01 USD =
0.01 USD
(PrivateLink data processing cost) - 73.00 USD + 0.01 USD =
73.01 USD
(Total PrivateLink endpoints and data processing cost)
- 730 hours in a month x 0.01 USD =
- NAT Gatewayについての課金
- 730 hours in a month x 0.045 USD =
32.85 USD
(Gateway usage hourly cost) - 10 GB per month x 0.045 USD =
0.45 USD
(NAT Gateway data processing cost) - 32.85 USD + 0.45 USD =
33.30 USD
(NAT Gateway processing and month hours) - 1 NAT Gateways x 33.30 USD =
33.30 USD
(Total NAT Gateway usage and data processing cost)
- 730 hours in a month x 0.045 USD =
2. Network Firewallで頑張る構成
前提は以下の通りです。
- 1ヶ月730時間で計算
- バージニア北部(us-east-1)
- Single-AZ構成
- Network Firewallのエンドポイント数:
1
- NAT Gatewayの数:
1
- 1ヶ月あたりのNetwork Firewallのデータ処理量:
21GB
計算した結果289.715 USD
となりました。
内訳は以下のようになりました。
- Network Firewallについての課金
- 730 hours in a month x 0.395 USD =
288.35 USD
(Hourly cost for endpoint ENI) - 21 GB per month x 0.065 USD =
1.365 USD
(Network Firewall data processing cost) - 288.35 USD + 1.365 USD =
289.715 USD
- 730 hours in a month x 0.395 USD =
求められる機能・役割が全く異なるので単純な比較はできませんが、VPCエンドポイントを使うためだけにNetwork Firewallを使うのは得策ではないですね。
Network Firewallは本来の使い方で使おう
唐突にVPCエンドポイント不要論を提唱しましたが、いろいろ考えてみるとVPCエンドポイントは必要でした。餅は餅屋、セキュアにアクセスするならVPCエンドポイントです。 次は大人しくIPS機能の記事でも書こうと思います。
この記事が誰かの助けになれば幸いです。
以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!
CDKのおまけ
あまり前回と変更点はありませんが、5-tuple
についてもCDKで書いたので掲載します。
import * as cdk from "@aws-cdk/core"; import * as ec2 from "@aws-cdk/aws-ec2"; import * as logs from "@aws-cdk/aws-logs"; import * as iam from "@aws-cdk/aws-iam"; import * as networkfirewall from "@aws-cdk/aws-networkfirewall"; export class AppStack extends cdk.Stack { constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // Create CloudWatch Logs for VPC Flow Logs const flowLogsLogGroup = new logs.LogGroup(this, "FlowLogsLogGroup", { retention: logs.RetentionDays.ONE_WEEK, }); // Create CloudWatch Logs for Network Firewall Logs const networkFirewallFlowLogsLogGroup = new logs.LogGroup( this, "NetworkFirewallFlowLogsLogGroup", { retention: logs.RetentionDays.ONE_WEEK, } ); // Create VPC Flow Logs IAM role const flowLogsIamrole = new iam.Role(this, "FlowLogsIamrole", { assumedBy: new iam.ServicePrincipal("vpc-flow-logs.amazonaws.com"), }); // Create SSM IAM role const ssmIamRole = new iam.Role(this, "SsmIamRole", { assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName( "AmazonSSMManagedInstanceCore" ), iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMPatchAssociation"), iam.ManagedPolicy.fromAwsManagedPolicyName( "CloudWatchAgentAdminPolicy" ), ], }); // Create VPC Flow Logs IAM Policy const flowLogsIamPolicy = new iam.Policy(this, "FlowLogsIamPolicy", { statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["iam:PassRole"], resources: [flowLogsIamrole.roleArn], }), new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams", ], resources: [flowLogsLogGroup.logGroupArn], }), ], }); // Atach VPC Flow Logs IAM Policy flowLogsIamrole.attachInlinePolicy(flowLogsIamPolicy); // Create VPC const vpc = new ec2.Vpc(this, "Vpc", { cidr: "10.0.0.0/16", enableDnsHostnames: true, enableDnsSupport: true, natGateways: 1, maxAzs: 1, subnetConfiguration: [ { name: "Firewall", subnetType: ec2.SubnetType.PRIVATE, cidrMask: 28 }, { name: "Public", subnetType: ec2.SubnetType.PUBLIC, cidrMask: 24 }, { name: "Private", subnetType: ec2.SubnetType.PRIVATE, cidrMask: 24 }, ], }); // Setting VPC Flow Logs new ec2.CfnFlowLog(this, "FlowLogToLogs", { resourceId: vpc.vpcId, resourceType: "VPC", trafficType: "ALL", deliverLogsPermissionArn: flowLogsIamrole.roleArn, logDestination: flowLogsLogGroup.logGroupArn, logDestinationType: "cloud-watch-logs", logFormat: "${version} ${account-id} ${interface-id} ${srcaddr} ${dstaddr} ${srcport} ${dstport} ${protocol} ${packets} ${bytes} ${start} ${end} ${action} ${log-status} ${vpc-id} ${subnet-id} ${instance-id} ${tcp-flags} ${type} ${pkt-srcaddr} ${pkt-dstaddr} ${region} ${az-id} ${sublocation-type} ${sublocation-id} ${pkt-src-aws-service} ${pkt-dst-aws-service} ${flow-direction} ${traffic-path}", maxAggregationInterval: 60, }); // Get Network Firewall Subnet ID const firewallSubnetId = new Array(); vpc .selectSubnets({ subnetGroupName: "Firewall" }) .subnets.forEach((subnet) => { firewallSubnetId.push({ subnetId: subnet.subnetId }); }); // Create EC2 instance vpc .selectSubnets({ subnetGroupName: "Private" }) .subnets.forEach((subnet, index) => { new ec2.Instance(this, `Ec2Instance${index}`, { machineImage: ec2.MachineImage.latestAmazonLinux({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2, }), instanceType: new ec2.InstanceType("t3.micro"), vpc: vpc, keyName: this.node.tryGetContext("key-pair"), role: ssmIamRole, vpcSubnets: vpc.selectSubnets({ subnetGroupName: "Private" }), }); }); // Create Network Firewall rule group // Domain List const networkfirewallRuleGroupDomainList = new networkfirewall.CfnRuleGroup( this, "NetworkfirewallRuleGroupDomainList", { capacity: 100, ruleGroupName: "AWSAPIDomainListRuleGroup", type: "STATEFUL", ruleGroup: { rulesSource: { rulesSourceList: { generatedRulesType: "ALLOWLIST", targetTypes: ["TLS_SNI", "HTTP_HOST"], targets: [ "ssm.us-east-1.amazonaws.com", "ssmmessages.us-east-1.amazonaws.com", "ec2messages.us-east-1.amazonaws.com", ], }, }, }, } ); // 5 tuple const networkfirewallRuleGroup5tuple = new networkfirewall.CfnRuleGroup( this, "NetworkfirewallRuleGroup5tuple", { capacity: 100, ruleGroupName: "AWSAPI5tupletRuleGroup", type: "STATEFUL", ruleGroup: { rulesSource: { statefulRules: [ { action: "PASS", header: { destination: "0.0.0.0/0", destinationPort: "443", direction: "ANY", protocol: "TCP", source: vpc.vpcCidrBlock, sourcePort: "ANY", }, ruleOptions: [ { keyword: `msg:"tcp/443 pass"`, }, { keyword: "sid:1000001", }, { keyword: "rev:1", }, ], }, ], }, }, } ); // Create Network Firewall policy const networkfirewallPolicy = new networkfirewall.CfnFirewallPolicy( this, "NetworkFirewallPolicy", { firewallPolicyName: "AWSAPIPolicy", firewallPolicy: { statelessDefaultActions: ["aws:forward_to_sfe"], statelessFragmentDefaultActions: ["aws:forward_to_sfe"], statefulRuleGroupReferences: [ { resourceArn: networkfirewallRuleGroupDomainList.attrRuleGroupArn, }, { resourceArn: networkfirewallRuleGroup5tuple.attrRuleGroupArn, }, ], }, } ); // Create Network Firewall const networkFirewall = new networkfirewall.CfnFirewall( this, "NetworkFirewall", { firewallName: "NetworkFirewall", firewallPolicyArn: networkfirewallPolicy.attrFirewallPolicyArn, vpcId: vpc.vpcId, subnetMappings: firewallSubnetId, } ); // // Setting Network Firewall logs new networkfirewall.CfnLoggingConfiguration( this, "NetworkFirewallFlowLogsToLogs", { firewallArn: networkFirewall.ref, loggingConfiguration: { logDestinationConfigs: [ { logDestination: { logGroup: networkFirewallFlowLogsLogGroup.logGroupName, }, logDestinationType: "CloudWatchLogs", logType: "FLOW", }, ], }, } ); // Routing NAT Gateway to Network Firewall vpc .selectSubnets({ subnetGroupName: "Public" }) .subnets.forEach((subnet, index) => { const route = subnet.node.children.find( (child) => child.node.id == "DefaultRoute" ) as ec2.CfnRoute; route.addDeletionOverride("Properties.GatewayId"); route.addOverride( "Properties.VpcEndpointId", cdk.Fn.select( 1, cdk.Fn.split( ":", cdk.Fn.select(index, networkFirewall.attrEndpointIds) ) ) ); }); // Routing Network Firewall to Internet Gateway vpc .selectSubnets({ subnetGroupName: "Firewall" }) .subnets.forEach((subnet, index) => { const route = subnet.node.children.find( (child) => child.node.id == "DefaultRoute" ) as ec2.CfnRoute; route.addDeletionOverride("Properties.NatGatewayId"); route.addOverride("Properties.GatewayId", vpc.internetGatewayId); }); // Internet Gateway RouteTable const igwRouteTable = new ec2.CfnRouteTable(this, "IgwRouteTable", { vpcId: vpc.vpcId, }); // Routing Internet Gateway to Network Firewall vpc .selectSubnets({ subnetGroupName: "Public" }) .subnets.forEach((subnet, index) => { new ec2.CfnRoute(this, `IgwRouteTableToFirewall${index}`, { routeTableId: igwRouteTable.ref, destinationCidrBlock: subnet.ipv4CidrBlock, vpcEndpointId: cdk.Fn.select( 1, cdk.Fn.split( ":", cdk.Fn.select(index, networkFirewall.attrEndpointIds) ) ), }); }); // Association Internet Gateway RouteTable new ec2.CfnGatewayRouteTableAssociation(this, "IgwRouteTableAssociation", { gatewayId: <string>vpc.internetGatewayId, routeTableId: igwRouteTable.ref, }); } }